/*
 * Copyright 2010 Kevin L'Huillier <klhuillier@gmail.com>
 * 
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 * 
 * http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */

package org.lhuillier.pwsafe.io.codec;


import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Map;
import org.lhuillier.pwsafe.io.RawDb;
import org.lhuillier.pwsafe.model.BooleanPrefType;
import org.lhuillier.pwsafe.model.Database;
import org.lhuillier.pwsafe.model.IntegerPrefType;
import org.lhuillier.pwsafe.model.PrefStorageType;
import org.lhuillier.pwsafe.model.Preferences;
import org.lhuillier.pwsafe.model.RawField;
import org.lhuillier.pwsafe.model.StringPrefType;
import com.google.inject.Inject;


/**
 * Converts the preferences database field to and from a string and raw data.
 * <p>
 * This will store the preferences in the same order as the original
 * Password Safe, sorted first by type then by IDs. The order of types is
 * booleans, ints, then strings.
 * <p>
 * This will encode all <i>unknown</i> preferences. Since these values would
 * have been loaded from the database and their values would not have been
 * changed (and indeed, we wouldn't know if they were the default value or not),
 * they should be written back to the DB.
 */
class PrefsConverter implements HeaderConverter {
    // The same delimiters used by PwSafe
    private static final char[] DELIMS = {
        '"', '\'', '#', '?', '!', '%', '&', '*', '+', '=', ':', ';', '@', '~',
        '<', '>', '?', ',', '.', '{', '}', '[', ']', '(', ')', '\u00bb'
    };
    private static final byte TYPE_ID = 0x02;
    private final BinConverter binConv;

    @Inject
    PrefsConverter(BinConverter binConv) {
        this.binConv = binConv;
    }
    
    @Override
    public byte handledTypeId() {
        return TYPE_ID;
    }

    @Override
    public void toDb(RawField raw, Database db) {
        String str = binConv.readString(raw.getValue());
        parseInto(db.getPreferences(), str);
    }

    @Override
    public void toRaw(Database db, RawDb raw) {
        String encoded = encode(db.getPreferences());
        if (encoded != null && !encoded.isEmpty()) {
            raw.addHeader(new RawField(TYPE_ID, binConv.writeString(encoded)));
        }
    }
    
    void parseInto(Preferences prefs, String value) {
        StringReader reader = new StringReader(value);

        try {
            readAll(prefs, reader);
        } catch (IOException e) {
            throw new RuntimeException("Unexpected exception", e);
        }
    }

    private void readAll(Preferences prefs, StringReader reader) 
            throws IOException {

        while (reader.ready()) {
            int next = reader.read();

            if (next < 0) {
                return;
            }

            switch (next) {
            case ' ': continue;
            case 'B': readBool(prefs, reader); break;
            case 'I': readInt(prefs, reader); break;
            case 'S': readStr(prefs, reader); break;
            default: throw new IllegalArgumentException(
                    "argument is not a valid preferences value");
            }
        }
    }

    private void readBool(Preferences prefs, StringReader reader)
            throws IOException {
        reader.read();
        int id = readInt(reader);
        int value = reader.read();
        prefs.setBoolean(id, value == '1');
    }

    private void readInt(Preferences prefs, StringReader reader)
            throws IOException {
        reader.read();
        int id = readInt(reader);
        int value = readInt(reader);
        prefs.setInteger(id, value);
    }

    private void readStr(Preferences prefs, StringReader reader)
            throws IOException {
        reader.read();
        int id = readInt(reader);
        StringBuilder value = new StringBuilder();
        char delim = (char) reader.read();
        char next = (char) reader.read();

        while (next != delim) {
            value.append(next);
            next = (char) reader.read();
        }

        prefs.setString(id, value.toString());
    }

    private int readInt(StringReader reader) throws IOException {
        int result = 0;
        int next = reader.read();

        while (next != ' ') {
            result *= 10;
            result += next - '0';
            next = reader.read();
        }

        return result;
    }
    
    String encode(Preferences preferences) {
        StringBuilder result = new StringBuilder();
        addBools(result, preferences);
        addInts(result, preferences);
        addStrings(result, preferences);
        return result.toString();
    }

    private boolean boolStoredInDb(int id) {
        BooleanPrefType prefType = BooleanPrefType.forId(id);
        return prefType == null
                || prefType.getStorageType() == PrefStorageType.DATABASE;
    }

    private boolean intStoredInDb(int id) {
        IntegerPrefType prefType = IntegerPrefType.forId(id);
        return prefType == null
                || prefType.getStorageType() == PrefStorageType.DATABASE;
    }

    private boolean strStoredInDb(int id) {
        StringPrefType prefType = StringPrefType.forId(id);
        return prefType == null
                || prefType.getStorageType() == PrefStorageType.DATABASE;
    }
    
    private ArrayList<Integer> ids(Map<Integer, ?> map) {
        ArrayList<Integer> ids = new ArrayList<Integer>(map.keySet());
        Collections.sort(ids);
        return ids;
    }

    private void addBools(StringBuilder result, Preferences preferences) {
        Map<Integer, Boolean> map = preferences.nonDefaultBooleans();
        for (Integer id : ids(map)) {
            if (boolStoredInDb(id)) {
                result.append(formatBoolean(id, map.get(id)));
            }
        }
    }

    private String formatBoolean(int id, Boolean value) {
        return String.format("B %d %d ", id, value ? 1 : 0);
    }

    private void addInts(StringBuilder result, Preferences preferences) {
        Map<Integer, Integer> map = preferences.nonDefaultIntegers();
        for (Integer id : ids(map)) {
            if (intStoredInDb(id)) {
                result.append(formatInt(id, map.get(id)));
            }
        }
    }

    private String formatInt(int id, Integer value) {
        return String.format("I %d %d ", id, value);
    }

    private void addStrings(StringBuilder result, Preferences preferences) {
        Map<Integer, String> map = preferences.nonDefaultStrings();
        for (Integer id : ids(map)) {
            if (strStoredInDb(id)) {
                result.append(formatString(id, map.get(id)));
            }
        }
    }

    private String formatString(int id, String value) {
        char delim = selectDelimiter(value);
        return String.format("S %d %s%s%s ", id, delim, value, delim);
    }

    /**
     * Finds the first character from the string of available delimiters which
     * does not appear in the value, and thus can be used for the delimiter for
     * this value.
     * <p>
     * This will throw an exception if all delimiters appear in the string.
     * In theory this should never happen. The odds of a string
     * containing all 26 delimiters is very poor. In most cases, " will
     * work for the string preferences that are being stored. No documents
     * are being stored as preferences, just things like default username,
     * column names, sample password, etc.
     */
    private char selectDelimiter(String value) {
        for (char ch : DELIMS) {
            if (value.indexOf(ch) < 0) {
                return ch;
            }
        }
        throw new RuntimeException("Cannot encode string: " + value);
    }

}
